import {
  EventDispatcher,
  MOUSE,
  Quaternion,
  Spherical,
  TOUCH,
  Vector2,
  Vector3,
  Plane,
  Ray,
  MathUtils
} from 'three'

// OrbitControls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
//
//    Orbit - left mouse / touch: one-finger move
//    Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
//    Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move

const _changeEvent = { type: 'change' }
const _startEvent = { type: 'start' }
const _endEvent = { type: 'end' }
const _ray = new Ray()
const _plane = new Plane()
const TILT_LIMIT = Math.cos(70 * MathUtils.DEG2RAD)

class OrbitControls extends EventDispatcher {
  constructor (object, domElement) {
    super()

    this.object = object
    this.domElement = domElement
    this.domElement.style.touchAction = 'none' // disable touch scroll

    // Set to false to disable this control
    this.enabled = true

    // "target" sets the location of focus, where the object orbits around
    this.target = new Vector3()

    // Sets the 3D cursor (similar to Blender), from which the maxTargetRadius takes effect
    this.cursor = new Vector3()

    // How far you can dolly in and out ( PerspectiveCamera only )
    this.minDistance = 0
    this.maxDistance = Infinity

    // How far you can zoom in and out ( OrthographicCamera only )
    this.minZoom = 0
    this.maxZoom = Infinity

    // Limit camera target within a spherical area around the cursor
    this.minTargetRadius = 0
    this.maxTargetRadius = Infinity

    // How far you can orbit vertically, upper and lower limits.
    // Range is 0 to Math.PI radians.
    this.minPolarAngle = 0 // radians
    this.maxPolarAngle = Math.PI // radians

    // How far you can orbit horizontally, upper and lower limits.
    // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
    this.minAzimuthAngle = -Infinity // radians
    this.maxAzimuthAngle = Infinity // radians

    // Set to true to enable damping (inertia)
    // If damping is enabled, you must call controls.update() in your animation loop
    this.enableDamping = false
    this.dampingFactor = 0.05

    // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
    // Set to false to disable zooming
    this.enableZoom = true
    this.zoomSpeed = 1.0
    this.keyZoomSpeed = 100.0

    // Set to false to disable rotating
    this.enableRotate = true
    this.rotateSpeed = 1.0
    this.keyRotateFactor = 1.0

    // Set to false to disable panning
    this.enablePan = true
    this.panSpeed = 1.0
    this.screenSpacePanning = true // if false, pan orthogonal to world-space direction camera.up
    this.keyPanSpeed = 7.0 // pixels moved per arrow key push
    this.zoomToCursor = false

    // Set to true to automatically rotate around the target
    // If auto-rotate is enabled, you must call controls.update() in your animation loop
    this.autoRotate = false
    this.autoRotateSpeed = 2.0 // 30 seconds per orbit when fps is 60

    // The four arrow keys
    this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown', IN: 'Equal', OUT: 'Minus' }

    // Mouse buttons
    this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }

    // Touch fingers
    this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }

    // for reset
    this.target0 = this.target.clone()
    this.position0 = this.object.position.clone()
    this.zoom0 = this.object.zoom

    // the target DOM element for key events
    this._domElementKeyEvents = null

    //
    // public methods
    //

    this.getPolarAngle = function () {
      return spherical.phi
    }

    this.getAzimuthalAngle = function () {
      return spherical.theta
    }

    this.getDistance = function () {
      return this.object.position.distanceTo(this.target)
    }

    this.listenToKeyEvents = function (domElement) {
      domElement.addEventListener('keydown', onKeyDown)
      this._domElementKeyEvents = domElement
    }

    this.stopListenToKeyEvents = function () {
      this._domElementKeyEvents.removeEventListener('keydown', onKeyDown)
      this._domElementKeyEvents = null
    }

    this.saveState = function () {
      scope.target0.copy(scope.target)
      scope.position0.copy(scope.object.position)
      scope.zoom0 = scope.object.zoom
    }

    this.reset = function () {
      scope.target.copy(scope.target0)
      scope.object.position.copy(scope.position0)
      scope.object.zoom = scope.zoom0

      scope.object.updateProjectionMatrix()
      scope.dispatchEvent(_changeEvent)

      scope.update()

      state = STATE.NONE
    }

    // this method is exposed, but perhaps it would be better if we can make it private...
    this.update = (function () {
      const offset = new Vector3()

      // so camera.up is the orbit axis
      const quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0))
      const quatInverse = quat.clone().invert()

      const lastPosition = new Vector3()
      const lastQuaternion = new Quaternion()
      const lastTargetPosition = new Vector3()

      const twoPI = 2 * Math.PI

      return function update (deltaTime = null) {
        const position = scope.object.position

        offset.copy(position).sub(scope.target)

        // rotate offset to "y-axis-is-up" space
        offset.applyQuaternion(quat)

        // angle from z-axis around y-axis
        spherical.setFromVector3(offset)

        if (scope.autoRotate && state === STATE.NONE) {
          rotateLeft(getAutoRotationAngle(deltaTime))
        }

        if (scope.enableDamping) {
          spherical.theta += sphericalDelta.theta * scope.dampingFactor
          spherical.phi += sphericalDelta.phi * scope.dampingFactor
        } else {
          spherical.theta += sphericalDelta.theta
          spherical.phi += sphericalDelta.phi
        }

        // restrict theta to be between desired limits

        let min = scope.minAzimuthAngle
        let max = scope.maxAzimuthAngle

        if (isFinite(min) && isFinite(max)) {
          if (min < -Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI

          if (max < -Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI

          if (min <= max) {
            spherical.theta = Math.max(min, Math.min(max, spherical.theta))
          } else {
            spherical.theta = (spherical.theta > (min + max) / 2)
              ? Math.max(min, spherical.theta)
              : Math.min(max, spherical.theta)
          }
        }

        // restrict phi to be between desired limits
        spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi))

        spherical.makeSafe()

        // move target to panned location

        if (scope.enableDamping === true) {
          scope.target.addScaledVector(panOffset, scope.dampingFactor)
        } else {
          scope.target.add(panOffset)
        }

        // Limit the target distance from the cursor to create a sphere around the center of interest
        scope.target.sub(scope.cursor)
        scope.target.clampLength(scope.minTargetRadius, scope.maxTargetRadius)
        scope.target.add(scope.cursor)

        let zoomChanged = false
        // adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
        // we adjust zoom later in these cases
        if ((scope.zoomToCursor && performCursorZoom) || scope.object.isOrthographicCamera) {
          spherical.radius = clampDistance(spherical.radius)
        } else {
          const prevRadius = spherical.radius
          spherical.radius = clampDistance(spherical.radius * scale)
          zoomChanged = prevRadius !== spherical.radius
        }

        offset.setFromSpherical(spherical)

        // rotate offset back to "camera-up-vector-is-up" space
        offset.applyQuaternion(quatInverse)

        position.copy(scope.target).add(offset)

        scope.object.lookAt(scope.target)

        if (scope.enableDamping === true) {
          sphericalDelta.theta *= (1 - scope.dampingFactor)
          sphericalDelta.phi *= (1 - scope.dampingFactor)

          panOffset.multiplyScalar(1 - scope.dampingFactor)
        } else {
          sphericalDelta.set(0, 0, 0)

          panOffset.set(0, 0, 0)
        }

        // adjust camera position
        if (scope.zoomToCursor && performCursorZoom) {
          let newRadius = null
          if (scope.object.isPerspectiveCamera) {
            // move the camera down the pointer ray
            // this method avoids floating point error
            const prevRadius = offset.length()
            newRadius = clampDistance(prevRadius * scale)

            const radiusDelta = prevRadius - newRadius
            scope.object.position.addScaledVector(dollyDirection, radiusDelta)
            scope.object.updateMatrixWorld()

            zoomChanged = !!radiusDelta
          } else if (scope.object.isOrthographicCamera) {
            // adjust the ortho camera position based on zoom changes
            const mouseBefore = new Vector3(mouse.x, mouse.y, 0)
            mouseBefore.unproject(scope.object)

            const prevZoom = scope.object.zoom
            scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / scale))
            scope.object.updateProjectionMatrix()

            zoomChanged = prevZoom !== scope.object.zoom

            const mouseAfter = new Vector3(mouse.x, mouse.y, 0)
            mouseAfter.unproject(scope.object)

            scope.object.position.sub(mouseAfter).add(mouseBefore)
            scope.object.updateMatrixWorld()

            newRadius = offset.length()
          } else {
            console.warn('WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.')
            scope.zoomToCursor = false
          }

          // handle the placement of the target
          if (newRadius !== null) {
            if (this.screenSpacePanning) {
              // position the orbit target in front of the new camera position
              scope.target.set(0, 0, -1)
                .transformDirection(scope.object.matrix)
                .multiplyScalar(newRadius)
                .add(scope.object.position)
            } else {
              // get the ray and translation plane to compute target
              _ray.origin.copy(scope.object.position)
              _ray.direction.set(0, 0, -1).transformDirection(scope.object.matrix)

              // if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
              // extremely large values
              if (Math.abs(scope.object.up.dot(_ray.direction)) < TILT_LIMIT) {
                object.lookAt(scope.target)
              } else {
                _plane.setFromNormalAndCoplanarPoint(scope.object.up, scope.target)
                _ray.intersectPlane(_plane, scope.target)
              }
            }
          }
        } else if (scope.object.isOrthographicCamera) {
          const prevZoom = scope.object.zoom
          scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / scale))

          if (prevZoom !== scope.object.zoom) {
            scope.object.updateProjectionMatrix()
            zoomChanged = true
          }
        }

        scale = 1
        performCursorZoom = false

        // update condition is:
        // min(camera displacement, camera rotation in radians)^2 > EPS
        // using small-angle approximation cos(x/2) = 1 - x^2 / 8

        if (zoomChanged ||
          lastPosition.distanceToSquared(scope.object.position) > EPS ||
          8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS ||
          lastTargetPosition.distanceToSquared(scope.target) > EPS) {
          scope.dispatchEvent(_changeEvent)

          lastPosition.copy(scope.object.position)
          lastQuaternion.copy(scope.object.quaternion)
          lastTargetPosition.copy(scope.target)

          return true
        }

        return false
      }
    }())

    this.dispose = function () {
      scope.domElement.removeEventListener('contextmenu', onContextMenu)

      scope.domElement.removeEventListener('pointerdown', onPointerDown)
      scope.domElement.removeEventListener('pointercancel', onPointerUp)
      scope.domElement.removeEventListener('wheel', onMouseWheel)

      scope.domElement.removeEventListener('pointermove', onPointerMove)
      scope.domElement.removeEventListener('pointerup', onPointerUp)

      const document = scope.domElement.getRootNode() // offscreen canvas compatibility

      document.removeEventListener('keydown', interceptControlDown, { capture: true })

      if (scope._domElementKeyEvents !== null) {
        scope._domElementKeyEvents.removeEventListener('keydown', onKeyDown)
        scope._domElementKeyEvents = null
      }

      // scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
    }

    //
    // internals
    //

    const scope = this

    const STATE = {
      NONE: -1,
      ROTATE: 0,
      DOLLY: 1,
      PAN: 2,
      TOUCH_ROTATE: 3,
      TOUCH_PAN: 4,
      TOUCH_DOLLY_PAN: 5,
      TOUCH_DOLLY_ROTATE: 6
    }

    let state = STATE.NONE

    const EPS = 0.000001

    // current position in spherical coordinates
    const spherical = new Spherical()
    const sphericalDelta = new Spherical()

    let scale = 1
    const panOffset = new Vector3()

    const rotateStart = new Vector2()
    const rotateEnd = new Vector2()
    const rotateDelta = new Vector2()

    const panStart = new Vector2()
    const panEnd = new Vector2()
    const panDelta = new Vector2()

    const dollyStart = new Vector2()
    const dollyEnd = new Vector2()
    const dollyDelta = new Vector2()

    const dollyDirection = new Vector3()
    const mouse = new Vector2()
    let performCursorZoom = false

    const pointers = []
    const pointerPositions = {}

    let controlActive = false

    function getAutoRotationAngle (deltaTime) {
      if (deltaTime !== null) {
        return (2 * Math.PI / 60 * scope.autoRotateSpeed) * deltaTime
      } else {
        return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed
      }
    }

    function getZoomScale (delta) {
      const normalizedDelta = Math.abs(delta * 0.01)
      return Math.pow(0.95, scope.zoomSpeed * normalizedDelta)
    }

    function rotateLeft (angle) {
      sphericalDelta.theta -= angle
    }

    function rotateUp (angle) {
      sphericalDelta.phi -= angle
    }

    const panLeft = (function () {
      const v = new Vector3()

      return function panLeft (distance, objectMatrix) {
        v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix
        v.multiplyScalar(-distance)

        panOffset.add(v)
      }
    }())

    const panUp = (function () {
      const v = new Vector3()

      return function panUp (distance, objectMatrix) {
        if (scope.screenSpacePanning === true) {
          v.setFromMatrixColumn(objectMatrix, 1)
        } else {
          v.setFromMatrixColumn(objectMatrix, 0)
          v.crossVectors(scope.object.up, v)
        }

        v.multiplyScalar(distance)

        panOffset.add(v)
      }
    }())

    // deltaX and deltaY are in pixels; right and down are positive
    const pan = (function () {
      const offset = new Vector3()

      return function pan (deltaX, deltaY) {
        const element = scope.domElement

        if (scope.object.isPerspectiveCamera) {
          // perspective
          const position = scope.object.position
          offset.copy(position).sub(scope.target)
          let targetDistance = offset.length()

          // half of the fov is center to top of screen
          targetDistance *= Math.tan((scope.object.fov / 2) * Math.PI / 180.0)

          // we use only clientHeight here so aspect ratio does not distort speed
          panLeft(2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix)
          panUp(2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix)
        } else if (scope.object.isOrthographicCamera) {
          // orthographic
          panLeft(deltaX * (scope.object.right - scope.object.left) / scope.object.zoom / element.clientWidth, scope.object.matrix)
          panUp(deltaY * (scope.object.top - scope.object.bottom) / scope.object.zoom / element.clientHeight, scope.object.matrix)
        } else {
          // camera neither orthographic nor perspective
          console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.')
          scope.enablePan = false
        }
      }
    }())

    function dollyOut (dollyScale) {
      if (scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera) {
        scale /= dollyScale
      } else {
        console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
        scope.enableZoom = false
      }
    }

    function dollyIn (dollyScale) {
      if (scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera) {
        scale *= dollyScale
      } else {
        console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
        scope.enableZoom = false
      }
    }

    function updateZoomParameters (x, y) {
      if (!scope.zoomToCursor) {
        return
      }

      performCursorZoom = true

      const rect = scope.domElement.getBoundingClientRect()
      const dx = x - rect.left
      const dy = y - rect.top
      const w = rect.width
      const h = rect.height

      mouse.x = (dx / w) * 2 - 1
      mouse.y = -(dy / h) * 2 + 1

      dollyDirection.set(mouse.x, mouse.y, 1).unproject(scope.object).sub(scope.object.position).normalize()
    }

    function clampDistance (dist) {
      return Math.max(scope.minDistance, Math.min(scope.maxDistance, dist))
    }

    //
    // event callbacks - update the object state
    //

    function handleMouseDownRotate (event) {
      rotateStart.set(event.clientX, event.clientY)
    }

    function handleMouseDownDolly (event) {
      updateZoomParameters(event.clientX, event.clientX)
      dollyStart.set(event.clientX, event.clientY)
    }

    function handleMouseDownPan (event) {
      panStart.set(event.clientX, event.clientY)
    }

    function handleMouseMoveRotate (event) {
      rotateEnd.set(event.clientX, event.clientY)

      rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)

      const element = scope.domElement

      rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight) // yes, height

      rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight)

      rotateStart.copy(rotateEnd)

      scope.update()
    }

    function handleMouseMoveDolly (event) {
      dollyEnd.set(event.clientX, event.clientY)

      dollyDelta.subVectors(dollyEnd, dollyStart)

      if (dollyDelta.y > 0) {
        dollyOut(getZoomScale(dollyDelta.y))
      } else if (dollyDelta.y < 0) {
        dollyIn(getZoomScale(dollyDelta.y))
      }

      dollyStart.copy(dollyEnd)

      scope.update()
    }

    function handleMouseMovePan (event) {
      panEnd.set(event.clientX, event.clientY)

      panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)

      pan(panDelta.x, panDelta.y)

      panStart.copy(panEnd)

      scope.update()
    }

    function handleMouseWheel (event) {
      updateZoomParameters(event.clientX, event.clientY)

      if (event.deltaY < 0) {
        dollyIn(getZoomScale(event.deltaY))
      } else if (event.deltaY > 0) {
        dollyOut(getZoomScale(event.deltaY))
      }

      scope.update()
    }

    function handleKeyDown (event) {
      let needsUpdate = false

      switch (event.code) {
        case scope.keys.UP:

          if (scope.enablePan && (event.ctrlKey || event.metaKey || event.shiftKey)) {
            pan(0, scope.keyPanSpeed)
          } else {
            rotateUp(-2 * Math.PI * scope.keyRotateFactor * scope.rotateSpeed / scope.domElement.clientHeight)
          }

          needsUpdate = true
          break

        case scope.keys.BOTTOM:

          if (scope.enablePan && (event.ctrlKey || event.metaKey || event.shiftKey)) {
            pan(0, -scope.keyPanSpeed)
          } else {
            rotateUp(2 * Math.PI * scope.keyRotateFactor * scope.rotateSpeed / scope.domElement.clientHeight)
          }

          needsUpdate = true
          break

        case scope.keys.LEFT:

          if (scope.enablePan && (event.ctrlKey || event.metaKey || event.shiftKey)) {
            pan(scope.keyPanSpeed, 0)
          } else {
            rotateLeft(-2 * Math.PI * scope.keyRotateFactor * scope.rotateSpeed / scope.domElement.clientHeight)
          }

          needsUpdate = true
          break

        case scope.keys.RIGHT:

          if (scope.enablePan && (event.ctrlKey || event.metaKey || event.shiftKey)) {
            pan(-scope.keyPanSpeed, 0)
          } else {
            rotateLeft(2 * Math.PI * scope.keyRotateFactor * scope.rotateSpeed / scope.domElement.clientHeight)
          }

          needsUpdate = true
          break

        case scope.keys.IN:
          if (scope.enableZoom) {
            dollyIn(getZoomScale(scope.keyZoomSpeed))
            needsUpdate = true
          }
          break

        case scope.keys.OUT:
          if (scope.enableZoom) {
            dollyOut(getZoomScale(scope.keyZoomSpeed))
            needsUpdate = true
          }
          break
      }

      if (needsUpdate) {
        // prevent the browser from scrolling on cursor keys
        event.preventDefault()

        scope.update()
      }
    }

    function handleTouchStartRotate (event) {
      if (pointers.length === 1) {
        rotateStart.set(event.pageX, event.pageY)
      } else {
        const position = getSecondPointerPosition(event)

        const x = 0.5 * (event.pageX + position.x)
        const y = 0.5 * (event.pageY + position.y)

        rotateStart.set(x, y)
      }
    }

    function handleTouchStartPan (event) {
      if (pointers.length === 1) {
        panStart.set(event.pageX, event.pageY)
      } else {
        const position = getSecondPointerPosition(event)

        const x = 0.5 * (event.pageX + position.x)
        const y = 0.5 * (event.pageY + position.y)

        panStart.set(x, y)
      }
    }

    function handleTouchStartDolly (event) {
      const position = getSecondPointerPosition(event)

      const dx = event.pageX - position.x
      const dy = event.pageY - position.y

      const distance = Math.sqrt(dx * dx + dy * dy)

      dollyStart.set(0, distance)
    }

    function handleTouchStartDollyPan (event) {
      if (scope.enableZoom) handleTouchStartDolly(event)

      if (scope.enablePan) handleTouchStartPan(event)
    }

    function handleTouchStartDollyRotate (event) {
      if (scope.enableZoom) handleTouchStartDolly(event)

      if (scope.enableRotate) handleTouchStartRotate(event)
    }

    function handleTouchMoveRotate (event) {
      if (pointers.length === 1) {
        rotateEnd.set(event.pageX, event.pageY)
      } else {
        const position = getSecondPointerPosition(event)

        const x = 0.5 * (event.pageX + position.x)
        const y = 0.5 * (event.pageY + position.y)

        rotateEnd.set(x, y)
      }

      rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)

      const element = scope.domElement

      rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight) // yes, height

      rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight)

      rotateStart.copy(rotateEnd)
    }

    function handleTouchMovePan (event) {
      if (pointers.length === 1) {
        panEnd.set(event.pageX, event.pageY)
      } else {
        const position = getSecondPointerPosition(event)

        const x = 0.5 * (event.pageX + position.x)
        const y = 0.5 * (event.pageY + position.y)

        panEnd.set(x, y)
      }

      panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)

      pan(panDelta.x, panDelta.y)

      panStart.copy(panEnd)
    }

    function handleTouchMoveDolly (event) {
      const position = getSecondPointerPosition(event)

      const dx = event.pageX - position.x
      const dy = event.pageY - position.y

      const distance = Math.sqrt(dx * dx + dy * dy)

      dollyEnd.set(0, distance)

      dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed))

      dollyOut(dollyDelta.y)

      dollyStart.copy(dollyEnd)

      const centerX = (event.pageX + position.x) * 0.5
      const centerY = (event.pageY + position.y) * 0.5

      updateZoomParameters(centerX, centerY)
    }

    function handleTouchMoveDollyPan (event) {
      if (scope.enableZoom) handleTouchMoveDolly(event)

      if (scope.enablePan) handleTouchMovePan(event)
    }

    function handleTouchMoveDollyRotate (event) {
      if (scope.enableZoom) handleTouchMoveDolly(event)

      if (scope.enableRotate) handleTouchMoveRotate(event)
    }

    //
    // event handlers - FSM: listen for events and reset state
    //

    function onPointerDown (event) {
      if (scope.enabled === false) return

      if (pointers.length === 0) {
        scope.domElement.setPointerCapture(event.pointerId)

        scope.domElement.addEventListener('pointermove', onPointerMove)
        scope.domElement.addEventListener('pointerup', onPointerUp)
      }

      //

      if (isTrackingPointer(event)) return

      //

      addPointer(event)

      if (event.pointerType === 'touch') {
        onTouchStart(event)
      } else {
        onMouseDown(event)
      }
    }

    function onPointerMove (event) {
      if (scope.enabled === false) return

      if (event.pointerType === 'touch') {
        onTouchMove(event)
      } else {
        onMouseMove(event)
      }
    }

    function onPointerUp (event) {
      removePointer(event)

      let pointerId = null
      let position = null

      switch (pointers.length) {
        case 0:

          scope.domElement.releasePointerCapture(event.pointerId)

          scope.domElement.removeEventListener('pointermove', onPointerMove)
          scope.domElement.removeEventListener('pointerup', onPointerUp)

          scope.dispatchEvent(_endEvent)

          state = STATE.NONE

          break

        case 1:
          pointerId = pointers[0]
          position = pointerPositions[pointerId]

          // minimal placeholder event - allows state correction on pointer-up
          onTouchStart({ pointerId, pageX: position.x, pageY: position.y })

          break
      }
    }

    function onMouseDown (event) {
      let mouseAction

      switch (event.button) {
        case 0:

          mouseAction = scope.mouseButtons.LEFT
          break

        case 1:

          mouseAction = scope.mouseButtons.MIDDLE
          break

        case 2:

          mouseAction = scope.mouseButtons.RIGHT
          break

        default:

          mouseAction = -1
      }

      switch (mouseAction) {
        case MOUSE.DOLLY:

          if (scope.enableZoom === false) return

          handleMouseDownDolly(event)

          state = STATE.DOLLY

          break

        case MOUSE.ROTATE:

          if (event.ctrlKey || event.metaKey || event.shiftKey) {
            if (scope.enablePan === false) return

            handleMouseDownPan(event)

            state = STATE.PAN
          } else {
            if (scope.enableRotate === false) return

            handleMouseDownRotate(event)

            state = STATE.ROTATE
          }

          break

        case MOUSE.PAN:

          if (event.ctrlKey || event.metaKey || event.shiftKey) {
            if (scope.enableRotate === false) return

            handleMouseDownRotate(event)

            state = STATE.ROTATE
          } else {
            if (scope.enablePan === false) return

            handleMouseDownPan(event)

            state = STATE.PAN
          }

          break

        default:

          state = STATE.NONE
      }

      if (state !== STATE.NONE) {
        scope.dispatchEvent(_startEvent)
      }
    }

    function onMouseMove (event) {
      switch (state) {
        case STATE.ROTATE:

          if (scope.enableRotate === false) return

          handleMouseMoveRotate(event)

          break

        case STATE.DOLLY:

          if (scope.enableZoom === false) return

          handleMouseMoveDolly(event)

          break

        case STATE.PAN:

          if (scope.enablePan === false) return

          handleMouseMovePan(event)

          break
      }
    }

    function onMouseWheel (event) {
      if (scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE) return

      event.preventDefault()

      scope.dispatchEvent(_startEvent)

      handleMouseWheel(customWheelEvent(event))

      scope.dispatchEvent(_endEvent)
    }

    function customWheelEvent (event) {
      const mode = event.deltaMode

      // minimal wheel event altered to meet delta-zoom demand
      const newEvent = {
        clientX: event.clientX,
        clientY: event.clientY,
        deltaY: event.deltaY
      }

      switch (mode) {
        case 1: // LINE_MODE
          newEvent.deltaY *= 16
          break

        case 2: // PAGE_MODE
          newEvent.deltaY *= 100
          break
      }

      // detect if event was triggered by pinching
      if (event.ctrlKey && !controlActive) {
        newEvent.deltaY *= 10
      }

      return newEvent
    }

    function interceptControlDown (event) {
      if (event.key === 'Control') {
        controlActive = true

        const document = scope.domElement.getRootNode() // offscreen canvas compatibility

        document.addEventListener('keyup', interceptControlUp, { passive: true, capture: true })
      }
    }

    function interceptControlUp (event) {
      if (event.key === 'Control') {
        controlActive = false

        const document = scope.domElement.getRootNode() // offscreen canvas compatibility

        document.removeEventListener('keyup', interceptControlUp, { passive: true, capture: true })
      }
    }

    function onKeyDown (event) {
      if (scope.enabled === false) return

      handleKeyDown(event)
    }

    function onTouchStart (event) {
      trackPointer(event)

      switch (pointers.length) {
        case 1:

          switch (scope.touches.ONE) {
            case TOUCH.ROTATE:

              if (scope.enableRotate === false) return

              handleTouchStartRotate(event)

              state = STATE.TOUCH_ROTATE

              break

            case TOUCH.PAN:

              if (scope.enablePan === false) return

              handleTouchStartPan(event)

              state = STATE.TOUCH_PAN

              break

            default:

              state = STATE.NONE
          }

          break

        case 2:

          switch (scope.touches.TWO) {
            case TOUCH.DOLLY_PAN:

              if (scope.enableZoom === false && scope.enablePan === false) return

              handleTouchStartDollyPan(event)

              state = STATE.TOUCH_DOLLY_PAN

              break

            case TOUCH.DOLLY_ROTATE:

              if (scope.enableZoom === false && scope.enableRotate === false) return

              handleTouchStartDollyRotate(event)

              state = STATE.TOUCH_DOLLY_ROTATE

              break

            default:

              state = STATE.NONE
          }

          break

        default:

          state = STATE.NONE
      }

      if (state !== STATE.NONE) {
        scope.dispatchEvent(_startEvent)
      }
    }

    function onTouchMove (event) {
      trackPointer(event)

      switch (state) {
        case STATE.TOUCH_ROTATE:

          if (scope.enableRotate === false) return

          handleTouchMoveRotate(event)

          scope.update()

          break

        case STATE.TOUCH_PAN:

          if (scope.enablePan === false) return

          handleTouchMovePan(event)

          scope.update()

          break

        case STATE.TOUCH_DOLLY_PAN:

          if (scope.enableZoom === false && scope.enablePan === false) return

          handleTouchMoveDollyPan(event)

          scope.update()

          break

        case STATE.TOUCH_DOLLY_ROTATE:

          if (scope.enableZoom === false && scope.enableRotate === false) return

          handleTouchMoveDollyRotate(event)

          scope.update()

          break

        default:

          state = STATE.NONE
      }
    }

    function onContextMenu (event) {
      if (scope.enabled === false) return

      event.preventDefault()
    }

    function addPointer (event) {
      pointers.push(event.pointerId)
    }

    function removePointer (event) {
      delete pointerPositions[event.pointerId]

      for (let i = 0; i < pointers.length; i++) {
        if (pointers[i] === event.pointerId) {
          pointers.splice(i, 1)
          return
        }
      }
    }

    function isTrackingPointer (event) {
      for (let i = 0; i < pointers.length; i++) {
        if (pointers[i] === event.pointerId) return true
      }

      return false
    }

    function trackPointer (event) {
      let position = pointerPositions[event.pointerId]

      if (position === undefined) {
        position = new Vector2()
        pointerPositions[event.pointerId] = position
      }

      position.set(event.pageX, event.pageY)
    }

    function getSecondPointerPosition (event) {
      const pointerId = (event.pointerId === pointers[0]) ? pointers[1] : pointers[0]

      return pointerPositions[pointerId]
    }

    //

    scope.domElement.addEventListener('contextmenu', onContextMenu)

    scope.domElement.addEventListener('pointerdown', onPointerDown)
    scope.domElement.addEventListener('pointercancel', onPointerUp)
    scope.domElement.addEventListener('wheel', onMouseWheel, { passive: false })

    const document = scope.domElement.getRootNode() // offscreen canvas compatibility

    document.addEventListener('keydown', interceptControlDown, { passive: true, capture: true })

    // force an update at start

    this.update()
  }
}

export { OrbitControls }
